最近在公司参与实现一套类似ant design功能的组件库。我在项目中主要负责Menu,Tab,Progress, Input,Radio,Checkbox,Select以及Form组件的开发。与其他样式组件不同,Form组件除了显示表单项状态(loading,成功,失败,警告)等以外,还需要对校验逻辑进行封装,提高表单开发效率。功能定位与ant design依赖的rc-menu一致。但个人认为rc-menu的api过于复杂。

设计思路

表单组件根据职责可以拆分为三部分:校验逻辑执行、表单数据管理、表单样式显示,因此表单组件由三部分组成:

  • validator.js提供与DOM无关的校验逻辑执行对象。职责为接受校验规则对象与表单数据对象,返回执行结果(即错误信息)。支持异步和多项联合校验。

  • ValidationFieldformCreatorformCreator为高阶组件。formCreator为被包装的组件管理表单数据,错误信息数据以及校验方法,并通过props和context与子组件通信。ValidationFieldformCreator配合使用,ValidationField接受props或者context的数据和方法将子组件(input、checkbox等)与formCreator的数据绑定。

  • FormItem(名字待定) 负责表单布局和表单输入组件状态的展示。

通过将表单分为这三部分,我们的组件有了更好的灵活性,如果不打算使用我们提供的校验方案,完全可以单独使用FormItem。

在组件的实现中,利用context的地方较多。之前由于了解不够,一直对react的context有所‘恐惧’,认为context是危险的(确实应该慎重使用context)。但经过一段时间的研究,发现合理的使用context是能够大量减少工作量,使得表单组件可以以更少的api覆盖绝大多数情况。有关context的问题请参考这里这里。在这儿还要说明一下,context最重要的问题是在pureComponent下,如果props或者state不变,那么context不能使组件更新。这个问题对于我来说不是问题,整个组件库全部未使用pureComponent或其他类似逻辑。而且context的使用全部由开发人员掌握,不会要求使用者对context处理(唯一的要求就是validationField与FormCreator返回的组件之间不要有purecomponent组件包裹)。此外使用context的组件均提供了对应的prop作为替代选项。

这里还需要说明组件库不使用pureComponent的原因,使用pureComponent优化性能在实际项目中很有必要,但是作为基础组件库,使用pureComponent会带来浅比较的成本,所以这里有个tradeoff。如果使用者真的需要pureComponent的话,完全可以在我们提供的组件上在包装一层。‘过早优化是万恶之源’这句话我感觉适用于这里的情况。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import React, { PureComponent } from 'react';

import {
formCreator,
FormItem,
ValidationField,
Input,
Button,
} from '../src';

const rules = {
name: {
validator(name) {
if (name === 'loading') {
return true;
}
return false;
},
message: name => `"${name}" is not my name!`,
},
};

class ValidateForm extends PureComponent {
render() {
const { onAllValidate } = this.props;
return (
<div>
<ValidationField label="Name" name="name" validateTrigger="onBlur">
<Input placeholder="值必须为loading" />
</ValidationField>
<ValidationField name="date" label="Birth" helperText="值必须为1995">
<Input />
</ValidationField>
<FormItem>
<Button onClick={onAllValidate}>check all</Button>
</FormItem>
</div>
);
}
}

const Demo = formCreator()(ValidateForm);

Comment and share

拖拽排序

最近的一个需求是图片拖拽排序功能,看到拖拽首先想到的“搬运”一下react-beautiful-dnd,毕竟在trello和jira里面已经见识过了,做的很漂亮。唯一的问题就是功能和体积都远远超过了我们的需求,所以自己实现更合适。

这里是Atlassian的团队分享的关于dnd开发的要点,可见交互良好的拖拽组件考虑的点还是很多的,而要实现这些特性,原生的drag事件其实是不够的,需要js全程模拟一遍,这个时间成本也是很高的,所以我的决定是简陋一点还是用原生拖拽事件吧。。。

实现

拖拽排序组件整体思路是,组件state的list数组管理需要排序的item的顺序,利用拖拽修改state。思路和实现很简单,我这里其实只是想说下实现过程的各种细节问题。

  1. 容器要阻止dragenter dragover drop默认行为,即调用preventDefault,drop还需要阻止冒泡。因为浏览器对拖拽有很多默认行为,比如Firefox下拖拽图片会在新的标签页面打开图片。

  2. 即使没有数据也最好随便设给dataTransfer设一个值,否则safari下会有兼容性问题。

  3. dragenter事件是以鼠标进入为基准触发的,这个特点其实在交互体验上不是很好,react-beautiful-dnd由于是自己实现,他们选择了根据拖拽元素的中心是否进入元素来判断。

  4. 跟随鼠标的被拖动元素的样式想要定制是非常困难的,这也是绝大多数库选择自己实现的原因。

其实整个移动的js代码就一行:list.splice(index, 0, list.splice(movingIndex, 1)[0]); movingIndex是被移动的元素。剩下的代码全部都是在处理兼容性和样式。。。

Comment and share

遥想在Flipboard实习时,我的第一个react项目里曾经有一个小需求就是为一个图集页面实现一个图片查看器。当时能力所限只实现了一版非常丑陋的勉强能用的,sad~如今再回头发现自己已经有能力实现一个体验更好的图片查看器,那么就弥补一下遗憾喽。

Demo

先放一下最后的成果。

demo

功能

  • 类似原生的滑动切换
  • 双击放大和还原
  • 图片拖动

实现

整个项目是用react16实现的,手势控制使用了alloyfinger,相较于另一个非常知名的web手势库hammer.js,alloyfinger更小,虽然功能可配置能力不如hammer.js,但是也足够用了。开发环境使用了storybook,storybook有比较丰富的插件,不过我这里没用(唯一个有迫切需求的移动端模拟插件,还是在下个版本才放出。。。)测试使用jest + enzyme。

实现过程中,有几个需要考虑的要点在这里记录一下。

切换

其实滑动切页是很常见的需求,jQuery的插件也数不胜数,但是这里面有个问题。很多插件会把所有图片都预先渲染好,如果图片数量较大,对于性能还是有一定影响的。理想状态下,应该只需要渲染最多三张,包括当前页,上一页,下一页。每次切换后再更新新的三页。

但是这样会带来新的问题,首先react在进行列表渲染时需要知道每一项的key,来决定如何优化,组件key发生变化会重新渲染整个组件,很多情况下没有必要,此外为了支持渲染两张相同url的图片,只使用url作为key也不合适。所以最后使用url+图片数组index来作为id。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const displayMax = index + 2 > images.length ? images.length : index + 2; //获取下一页的index
const displayMin = index - 1 < 0 ? 0 : index - 1; //获取上一页的index
return (
...
{images.slice(displayMin, displayMax).map((url, ind) => (
<div
key={url + (ind + displayMin)}
className="image-slides-blackboard">
{loaded[url] ? (
<img
className="image-slides-content"
src={url} />
) : Loading}
</div>
))}
)

其中images为url数组,index为当前页数。

另外一个问题是如果我们渲染所有图片,偏移量计算很简单index * window.innerWidth,翻到下一页,index 加一即可,但是如果只渲染3张的话,偏移量始终为1 * window.innerWidth,翻到下一页偏移量变化过程为1 * window.innerWidth -> 1 * window.innerWidth + 手指划过距离(直到触发index + 1) -> 1 * window.innerWidth 这样的话过渡动画会很奇怪,左右闪。所以我这里的实现为1 * window.innerWidth -> 1 * window.innerWidth + 手指划过距离(直到触发index + 1) -> 0 * window.innerWidth + 手指划过距离 -> 1 * window.innerWidth这里会在chrome上又又又碰到一坑。。。chrome在计算transform偏移时,如1 * window.innerWidth + 手指划过距离(直到触发index + 1) -> 0 * window.innerWidth + 手指划过距离 -> 1 * window.innerWidth会做优化将变化合并,即把0 * window.innerWidth + 手指划过距离这一步忽略了,所以我在这一步通过手动修改其他属性,来强制触发。翻到上一页同理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
gesturesManager.on('touchEnd', e => {
const swipeTrigger = window.innerWidth * 0.2; //触发翻页的偏移量
if (this.lastContainerOffsetX > swipeTrigger) { //上一页
if (this.getMedianIndex() > 0 && this.state.index !== 1) {
style.transform = `translate3d(${this.lastContainerOffsetX -
(GUTTER_WIDTH + window.innerWidth) * 2}px, 0, 0)`;
} else if (this.state.index === 1) {
style.transform = `translate3d(${this.lastContainerOffsetX -
(GUTTER_WIDTH + window.innerWidth)}px, 0, 0)`;
}
this.last();
} else if (this.lastContainerOffsetX < -swipeTrigger) { //下一页
style.transform = `translate3d(${this.lastContainerOffsetX}px, 0, 0)`; // 0 * window.innerWidth + 手指划过距离
if (this.state.index === 0) {
style.transition = 'all 0.3s';
}
this.next(); //触发翻页
}
style.transition = 'all 0.3s';
style.transform = `translate3d(${-(
GUTTER_WIDTH + window.innerWidth
) * this.getMedianIndex()}px, 0, 0)`; //恢复为1 * window.innerWidth + 手指划过距离
this.lastContainerOffsetX = 0;
this.isMoving = false;
e.preventDefault();
});

(后来想到另外一种做法是监听transitionend事件,再触发翻页)

拖动

其实这里和滑动翻页纠缠在一起,核心就是如何确定触摸事件触发图片拖动还是滑动翻页。在FLipboard时这个问题通过滚动条交给浏览器来解决,但是滚动条在pc上很丑陋,所以这次决定还是自己控制。控制优先级为:当图片拖动到边界时,控制权交给滑动翻页。用户双击放大控制权交还图片。超出边界判定的规则是图片偏移量大于图片宽度减去视口宽度的1/2.

1
2
3
4
5
6
if (
Math.abs(lastOffsetX + offsetX) > xRange && // xRange为 (clientWidth * scaleMultiples - this.viewPortWidth) / 2,
Math.abs(lastOffsetX + offsetX) > Math.abs(lastOffsetX)
) {
isInLimit = false;
}

优化和兼容

  • 对于图片元素设置will-change提升为合成层使用GPU加速。
  • 针对uc和webview中左滑后退,再touch事件中调用preventDefault。
  • 移动端需要解决滚动穿透的问题的(最简单的方案body高度设为0)

未解决的问题

  1. pinch手势放大缩小的功能没有实现,核心问题在于如何计算放大中心。
  2. 组件进入的时候缺少过渡效果。

Comment and share

  • page 1 of 1
Author's picture

loading

Web App Developer


web developer


Beijing